大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 26 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,最後建構出繪製 3D、光影效果之網頁。如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容
到了本系列文章的尾聲,本章節將製作一個完整的場景作為完結作品:主角是一艘帆船,在一片看不到邊的海面上,天氣晴朗。
06-boat-ocean.html
/ 06-boat-ocean.js
基於先前製作的光影效果,筆者製作了新的起始點:
此起始點相較於 Day 25,主要有以下修改:
twgl.createTextures()
使 texture 之讀取、建立程式可以大幅縮短twgl.resizeCanvasToDisplaySize()
取代 canvas 之大小調整,同時在右上角讓使用者調整解析度倍率,一般來說是 普通
也就是一倍,如果 window.devicePixelRatio
大於 1(例如 Retina 螢幕),使用者也可以提高使用完整的螢幕解析度可以看到畫面上有一顆從上上個章節就一直存在的球體,第一個目標就是將之換成新主角 -- 帆船
.obj
/ .mtl
檔案.obj
存放的是 3D 物件資料,精確的說,經過讀取後可以成為 vertex attribute 的資料來源,包含了各個頂點的位置、texcoord、法向量,成為 3D 場景中的一個物件,而 .mtl
則是存放材質資料,像是散射光、反射光的顏色等
筆者先前在練習 .obj
的讀取時,順便小小學習了 Blender 這套開源的 3D 建模軟體,並且製作了一艘船:
https://sketchfab.com/3d-models/my-first-boat-f505dd73384245e08765ea6824b12644
這個模型就成了筆者接下來練習時需要模型時使用的素材,同時也是我們要放入場景中的帆船,匯出成 .obj
& .mtl
之後,用文字編輯器打開其 .obj
可以看到:
# Blender v2.93.0 OBJ File: 'my-first-boat.blend'
# www.blender.org
mtllib my-first-boat.mtl
o Cube_Cube.001
v -0.245498 -0.021790 2.757482
v -0.551836 0.552017 2.746644
v -0.371110 -0.118091 0.326329
...
vt 0.559949 0.000000
vt 0.625000 0.000000
vt 0.625000 0.250000
...
vn -0.7759 -0.6250 0.0861
vn 0.0072 -0.0494 -0.9988
vn 0.7941 -0.6020 0.0836
...
usemtl body
...
f 17/1/1 2/2/1 4/3/1 18/4/1
f 18/4/2 4/3/2 8/5/2 19/6/2
f 19/6/3 8/5/3 6/7/3 20/8/3
...
o Cylinder.004_Cylinder.009
v 0.000000 0.308823 0.895517
v 0.000000 0.640209 0.895517
...
.obj
要紀錄 3D 物件的每個頂點資料,想當然爾檔案通常不小,這個模型有 20.6k 個三角形,檔案大小約 1.3MB,這邊不會看全部的細節,只擷取了一些小片段來觀察其內容
mtllib my-first-boat.mtl
的 mtllib
開頭表示使用了 my-first-boat.mtl
這個檔案來描述材質o Cube_Cube.001
的 o
開頭表示一個子物件的開始,Cube_Cube.001
這個名字來自於 blender 中的物件名稱v -0.245498 -0.021790 2.757482
/ vt 0.559949 0.000000
/ vn -0.7759 -0.6250 0.0861
的 v
/ vt
/ vn
開頭分別為位置、texcoord、法向量資料,實際去打開檔案可以看到 .obj
絕大部分的內容都像這樣usemtl body
表示這個子物件要使用的材質的名字,理論上可以在 .mtl
中找到對應的名字f 17/1/1 2/2/1 4/3/1 18/4/1
表示一個『面』,這邊是一個四邊形,有四個頂點,每個頂點分別用一個 index 數字表示使用的哪一筆位置、texcoord、法向量,類似於 Day 18 之 indexed elemento
開頭 o Cylinder.004_Cylinder.009
表示另一個子物件的開始,Cube_Cube.001
這個名字來自於 blender 中的物件名稱同樣地,看一下 .mtl
的片段:
# Blender MTL File: 'my-first-boat.blend'
# Material Count: 10
newmtl Material.001
Ns 225.000000
Ka 1.000000 1.000000 1.000000
Kd 0.352941 0.196078 0.047058
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.450000
d 1.000000
illum 2
...
newmtl flag-my-logo
Ns 225.000000
Ka 1.000000 1.000000 1.000000
Kd 0.000000 0.000000 0.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.450000
d 1.000000
illum 2
map_Kd me.png
這個檔案就相對小很多,newmtl Material.001
對應 .obj
中的材質名稱,接下來則是對於不同光線的顏色或參數,根據這邊的定義,Ka
表示環境光顏色、Kd
散射光顏色、Ks
反射光顏色、Ns
反射光『範圍參數』(u_specularExponent
),Ke
在 stackoverflow 上說是自發光的顏色;最後帆船模型中間船桅上有一面旗子,旗子中的圖案使用了 texture,因此 flag-my-logo
這個材質有一個參數 map_Kd me.png
表示要使用 me.png
這個圖檔作為 texture;剩下的設定在我們的 shader 中也沒有相關的實做,就先忽略不管
開始寫程式讀取 .obj
之前,可以從這邊下載 my-first-boat.obj
, my-first-boat.mtl
以及 me.png
放置在專案 assets/
資料夾下
這邊的
.mtl
檔案與上傳到 sketchfab.com 的有點不同,因為本系列文實做的 shader 會導致一些材質顏色不明顯,因此筆者有手動調整.mtl
部份材質的顏色
.obj
& .mtl
好的,綜觀來看,自己寫讀取程式的話,除了 .obj
/ .mtl
parser 之外,得從 f
開頭的『面』資料展開成一個個三角形,接著取得要使用的位置、texcoord、法向量,轉換成 buffer 作為 vertex attribute 使用,除此之外還要處理 .mtl
的對應、建立子物件等,顯然是個不小的工程;既然 .obj
是一種公用格式,那麼應該可以找到現成的讀取工具,筆者找到的是這款:
https://github.com/frenchtoast747/webgl-obj-loader
可惜作者沒有提供 ES module 的方式引入,因此筆者 fork 此專案並且修改使之可以產出 ES module 的版本:github.com/pastleo/webgl-obj-loader,下載 build 好的 webgl-obj-loader.esm.js
並放至於專案的 vendor/webgl-obj-loader.esm.js
,接著在 06-boat-ocean.js
就可以直接引入:
import * as WebGLObjLoader from './vendor/webgl-obj-loader.esm.js';
接著建立一個 function 來串接 WebGLObjLoader
讀取 .obj
, .mtl
並傳入 WebGL,經過一些 survey 之後筆者使用它的 WebGLObjLoader.downloadModels()
,可以同時下載所有需要的檔案並解析好,包含 .mtl
甚至 texture 圖檔,先看一下經過 WebGLObjLoader
讀取好的資料看起來如何:
async function loadBoatModel(gl) {
const { boatModel } = await WebGLObjLoader.downloadModels([{
name: 'boatModel',
obj: './assets/my-first-boat.obj',
mtl: true,
}]);
console.log(boatModel);
}
在 setup()
中呼叫:
async function setup() {
// ...
await loadBoatModel(gl);
// ...
}
配合其文件的說明,.vertices
對應 a_position
、.vertexNormals
對應 a_texcoord
、.textures
對應 a_normal
,但是這些 vertex attribute 不能直接使用,而是要透過 .indices
指向每個頂點對應的資料,同時 .indices
已經是 Day 18 的 indexed element 所需要之 ELEMENT_ARRAY_BUFFER
,不像是 .obj
中一個個 f
開頭的頂點指向不同組 position/texcoord/normal
那材質的部分呢?在我們的實作中同一個物件一次渲染只能指定一組 u_diffuse
, u_specular
等 uniform 讓物件為一個單色,要不然就是用 u_diffuseMap
指定 texture,直接使用 .indices
作為 ELEMENT_ARRAY_BUFFER
的話便無法使不同子物件使用不同的材質,幸好 WebGLObjLoader
所回傳的物件中有 .indicesPerMaterial
,裡面包含了一個個的 indices 陣列,分別對應一組材質設定,有趣的事情是,這些 indices 所對應的實際 vertex attribute 是共用的,也就是說 position/texcoord/normal 的 buffer 只要建立一組,接下來每個子物件建立各自的 indices buffer 並與共用 position/texcoord/normal 的 buffer 組成『物件』 VAO,最後渲染時各個物件設定好各自的 uniform 後進行繪製即可
因此在 WebGLObjLoader.downloadModels()
之後建立共用的 bufferInfo:
async function loadBoatModel(gl) {
const { boatModel } = await WebGLObjLoader.downloadModels([{ /* ... */ }]);
const sharedBufferInfo = twgl.createBufferInfoFromArrays(gl, {
position: { numComponents: 3, data: boatModel.vertices },
texcoord: { numComponents: 2, data: boatModel.textures },
normal: { numComponents: 3, data: boatModel.vertexNormals },
});
}
接下來讓 app.objects.boat
表示整艘帆船,但是要一個一個繪製子物件,因此使 app.objects.boat
為一個陣列,每一個元素包含子物件的 bufferInfo, VAO 以及 uniforms,從 boatModel.indicesPerMaterial.map()
出發:
async function loadBoatModel(gl, programInfo) {
// ...
return boatModel.indicesPerMaterial.map((indices, mtlIdx) => {
const material = boatModel.materialsByIndex[mtlIdx];
const bufferInfo = twgl.createBufferInfoFromArrays(gl, {
indices,
}, sharedBufferInfo);
return {
bufferInfo,
vao: twgl.createVAOFromBufferInfo(gl, programInfo, bufferInfo),
}
});
}
.indicesPerMaterial
陣列中,第幾個 indices 陣列就使用第幾個 material,因此 boatModel.materialsByIndex[mtlIdx];
取得對應的材質設定twgl.createBufferInfoFromArrays()
的第三個參數 srcBufferInfo
來『共用』剛才建立的 sharedBufferInfo
,這感覺其實有點像是 Map 的 merge 或是 Object.assign()
loadBoatModel()
傳入 programInfo
,以便建立 VAO這樣一來 vertex attribute buffer, indices buffer 以及 VAO 就準備好了,剩下的就是把材質資料轉成 uniforms key-value 物件,把這邊取得的 material
印出來看:
雖然這個物件有不少東西,不過要找到 u_diffuse
, u_specular
對應的資料不會很困難,名字幾乎能夠直接對起來;如果是有 texture 的,可以在 material.mapDiffuse.texture
找到,而且已經是 Image 物件,直接餵給 twgl.createTexture()
即可:
async function loadBoatModel(gl, textures, programInfo) {
// ...
return boatModel.indicesPerMaterial.map((indices, mtlIdx) => {
const material = boatModel.materialsByIndex[mtlIdx];
let u_diffuseMap = textures.nil;
if (material.mapDiffuse.texture) {
u_diffuseMap = twgl.createTexture(gl, {
wrapS: gl.CLAMP_TO_EDGE, wrapT: gl.CLAMP_TO_EDGE,
min: gl.LINEAR_MIPMAP_LINEAR,
src: material.mapDiffuse.texture,
});
}
return {
/* bufferInfo, vao */,
uniforms: {
u_diffuse: material.diffuse,
u_diffuseMap,
u_specular: material.specular,
u_specularExponent: material.specularExponent,
u_emissive: material.emissive,
u_ambient: [0.6, 0.6, 0.6],
},
}
});
}
對於沒有使用 texture 的子物件,就跟之前一樣要設定成 texture.nil
避免影響到單色渲染,令一個比較特別的是 u_ambient
,因為筆者為此系列文撰寫的 shader 運作方式與 blender、sketchfab.com 上看到的不同,或許是有些材質的設定沒實做的關係,會顯得特別暗,同時 u_ambient
這邊實做的功能是基於 diffuse 的最低亮度,因此筆者一律設定成 [0.6, 0.6, 0.6]
因為原本 u_ambient
為全域的 uniform,而之後會變成各個物件個別設定,最後在 setup()
中傳入所需的參數並接收子物件陣列到 app.objects.boat
準備好:
async function setup() {
// ...
+ objects.boat = await loadBoatModel(gl, textures, programInfo);
// ...
}
function render(app) {
// ...
const globalUniforms = {
u_worldViewerPosition: cameraMatrix.slice(12, 15),
u_lightDirection: lightDirection,
- u_ambient: [0.4, 0.4, 0.4],
// ...
}
}
function renderBall(app, viewMatrix, programInfo) {
// ...
twgl.setUniforms(programInfo, {
// ...
u_emissive: [0.15, 0.15, 0.15],
+ u_ambient: [0.4, 0.4, 0.4],
// ...
});
}
function renderOcean(app, viewMatrix, reflectionMatrix, programInfo) {
// ...
twgl.setUniforms(programInfo, {
// ...
u_emissive: [0, 0, 0],
+ u_ambient: [0.4, 0.4, 0.4],
// ...
});
// ...
}
這樣一來 app.objects.boat
就準備好帆船的資料了,雖然畫面上沒有變化,但是可以在 Console 上輸入 app.objects.boat
來確認:
確認資料準備好了,待下篇來把球體換成帆船,繪製 .obj
模型到畫面上!本篇的完整程式碼可以在這邊找到: